Skip to content

Conversation

@parasyte
Copy link
Contributor

@parasyte parasyte commented Feb 6, 2022

This was thrown together to start a discussion. There are no tests, and I am not happy with the implementation. Consider this PR an experiment with no intention to merge. But it could help define the feature and identify shortcomings.

Usage currently looks something like this:

use gdnative::{
    // GroupMarker is a special unit struct to deal with the way Godot property groups
    // are implemented in property lists.
    export::GroupMarker,
    prelude::*,
};

#[derive(NativeClass)]
#[inherit(Node)]
pub struct MyStruct {
    /// This property is _not_ grouped.
    #[property(default = 8.0, after_set = "Self::update_callback")]
    magnitude: f32,

    /// This property defines a _name_ for your group.
    #[property(group)]
    offset: GroupMarker,

    /// This property directly follows a GroupMarker, so it is a member of the group.
    /// The property name in the editor will be "Offset X".
    #[property(after_set = "Self::update_callback"]
    offset_x: f32,

    /// This property will also be a member of the previously defined group.
    /// The property name in the editor will be "Offset Y".
    #[property(after_set = "Self::update_callback"]
    offset_y: f32,

    /// This property defines a new group and sets a hint prefix for the editor.
    /// The prefix is used by the editor to match property names.
    /// The prefix is removed from the member names.
    #[property(group = "size_")]
    size: GroupMarker,

    /// This property has a prefix matching the previous GroupMarker, so it is a member of the group.
    /// The property name in the editor will be "Width".
    #[property(default = 5, after_set = "Self::update_callback"]
    size_width: u32,

    /// This property has a prefix matching the previous GroupMarker, so it is a member of the group.
    /// The property name in the editor will be "Height".
    #[property(default = 5, after_set = "Self::update_callback"]
    size_height: u32,

    /// This property _does not_ have a prefix matching the previous GroupMarker,
    /// so it is _not_ a member of the group.
    /// The property will appear in the editor below "Magnitude".
    /// This is the behavior of Godot.
    #[property(after_set = "Self::update_callback"]
    duration: f32,
}

How it looks in the editor:

property groups

Ideally, I believe that property groups should be declared structurally. The example above has a flat hierarchy that illustrates many issues:

  1. The code is generally not easy to read. It is difficult to tell at a glance which properties are grouped together.
  2. The editor hint provided by the group = "..." arg is easy to typo, which will ruin the grouping of items. Likewise, property names are only conventionally grouped by a prefix.
  3. Godot's behavior with property groups leaves a lot to be desired:
    • Defining a group requires a property whose value is Nil which provides a name for the group. In the example, size and offset are both group names, created automatically by the property attribute using the field name as the property's name. The GroupMarker type is just a way to feed the Nil value to Godot.
    • All properties following another property with the GROUP usage flag enabled will become a member of that group. (See the linked GDScript documentation.)
    • The behavior changes when a hint_string is provided on the property with the GROUP flag: In this case, only properties whose name matches the given hint prefix will become group members. Any other properties will "move" to the top of the list outside of any group. This behavior is surprising, and any number of bugs can cause this to happen by mistake.

With structural declaration, the example could possibly look like this:

use gdnative::prelude::*;

#[derive(PropertyGroup)]
struct Offset {
    /// Offset along X-axis in meters.
    #[property(after_set = "MyStruct::update_callback"]
    x: f32,

    /// Offset along Y-axis in meters.
    #[property(after_set = "MyStruct::update_callback"]
    y: f32,
}

#[derive(PropertyGroup)]
struct Size {
    /// Width in meters.
    #[property(default = 5, after_set = "MyStruct::update_callback"]
    width: u32,

    /// Height in meters.
    #[property(default = 5, after_set = "MyStruct::update_callback"]
    height: u32,
}

#[derive(NativeClass)]
#[inherit(Node)]
pub struct MyStruct {
    /// This property is _not_ grouped.
    #[property(default = 8.0, after_set = "Self::update_callback")]
    magnitude: f32,

    /// This property defines a _name_ for your group and all of its members.
    #[property(group, no_group_prefix)]
    offset: Offset,

    /// Define another group with its members.
    #[property(group)]
    size: Size,

    /// This property does not belong to a group.
    /// So it will appear in the editor below "Magnitude".
    /// This is the behavior of Godot.
    #[property(after_set = "Self::update_callback"]
    duration: f32,
}

This looks more reasonable, but it also has some problems:

  1. Godot properties must have a unique name. The derive macro will need to flatten the struct into a single-level property list with e.g. size.width being renamed to size_width and a hint on the group set to "size_" to handle the prefixing. Creating a group without prefix handling would have to be an extra option for the property attribute.
  2. The flattening means that getting a property by name may be surprising behavior! Users will have to know that the group creates magically prefixed property names if they ever need to look them up with Godot's property list functions.
  3. Hypothetically this could support arbitrary nesting depths, but for simplicity I personally prefer a maximum depth of 2.
  4. The way Godot "groups" ungrouped properties at the top of the list is a bit unexpected. But I guess they had to make the UI reasonable. There's nothing we can do about that.
  5. Lacking unnamed structs in Rust is unfortunate. We need to declare Offset and Size as separate types, and they both have to refer back to MyStruct for getter/setter functions. At least it makes sense to include hint functions on these separate types.
  6. Honestly, I don't know how well this structural property declaration will work. Do we still need #[property(group)] for this? Is ``#[derive(PropertyGroup)]` enough? Can we do something better?

@parasyte parasyte force-pushed the feature/derive-property-groups branch from c53620a to 234a37d Compare February 6, 2022 11:19
@Bromeon Bromeon added c: export Component: export (mod export, derive) feature Adds functionality to the library labels Feb 6, 2022
@Bromeon Bromeon added this to the v0.10.1 milestone Feb 6, 2022
@parasyte parasyte force-pushed the feature/derive-property-groups branch from 234a37d to 1828e4d Compare February 6, 2022 17:03
@parasyte parasyte force-pushed the feature/derive-property-groups branch from 1828e4d to 6832f39 Compare February 14, 2022 19:07
@Bromeon
Copy link
Member

Bromeon commented Apr 25, 2022

For Rust, we should probably follow a slightly different approach than in GDScript. I agree that the behavior is suprising and has room for improvement.

Some ideas what we could do better in Rust:

  1. Type safety + no magic
    String-based grouping can work in a small scope (and be actually simpler than something type-safe), but it's also easy to type-safe at compile time.
    The "prefix defines group" is also something very unusual in Rust, I'd rather have things explicit.

  2. Stateless
    The "previous declaration decides group" behavior is horrific and introduces arbitrary restrictions to Rust, such as not being able to reorder fields. Each field should determine its group affinity in a self-contained way.

  3. One way to do things
    I'd claim this is a relatively rarely used feature, so the API surface for it should be small. If the user wants to achieve a certain layout of the UI elements, there should ideally be one way to achieve this in Rust. That also helps recognizability across code.


I'm not yet sure about grouping via struct or via label (attribute), it depends a bit how this would be used. Struct would be the "more correct" way, but keep in mind that this means that the export layout determines how Rust code needs to access the fields. Which can be a good thing (when improving code structure), or super annoying (when forcing introduction of 5 new types for seemingly no benefit). I'd still say we should choose one way here.

In case we go for the structural approach, your 2nd proposed API already looks quite nice.

In a label-based world, I would probably change some things:

// Group is named "Offset", no prefix for fields
#[derive(PropertyGroup)]
struct Offset;

// Group is named "Size", fields prefixed "size_"
#[derive(PropertyGroup)]
#[group(title = "Size", prefix = "size_")]
struct SizeGroup;

#[derive(NativeClass)]
#[inherit(Node)]
pub struct MyStruct {
    // This property is _not_ grouped.
    // Exported as "magnitude"
    #[property(default = 8.0, set = "Self::set_magnitude")]
    magnitude: f32,

    // Exported as "offset_x"
    #[property(group = Offset)]
    offset_x: f32,

    // Exported as "offset_y"
    #[property(group = Offset)]
    offset_y: f32,

    // Exported as "size_width"
    #[property(group = SizeGroup)]
    width: u32,

    // Exported as "size_height"
    #[property(group = SizeGroup)]
    height: u32,

    // Not member of a group simply because there's no `group` attribute
    // No need to inspect any field declarations before
    #[property]
    duration: f32,
}

Not fully happy with the prefix magic though. We might as well use an explicit #[property(group = SizeGroup, rename = "size_width")] syntax even if it means some repetition...

@Bromeon
Copy link
Member

Bromeon commented Jun 5, 2022

@parasyte Any feedback on my ideas above? 💡

@chitoyuu
Copy link
Contributor

chitoyuu commented Dec 2, 2022

It seems like this PR has fallen into inactivity for a while, and has now drifted too far from master to be merged as is. @parasyte Please let us know if you're still interested in the idea. Otherwise, I'll be closing this in a few days.

Note that property grouping via renaming has been available since at least 0.9.2: #[property(path = "my_category/my_property_name")]. See https://godot-rust.github.io/docs/gdnative/derive/derive.NativeClass.html#property. Are there any mechanical differences between properties grouped this way and ones grouped via PropertyUsage::GROUP? This could help us determine if we should consider this as a missing feature, or purely a QoL improvement.

@parasyte
Copy link
Contributor Author

parasyte commented Dec 2, 2022

Closing this is the right thing to do. I can’t comment on how it compares to the #[property(path = "…")] attribute. But as long as it supports grouping the same way as shown in the screenshot then this PR has nothing new to offer. I’ll leave it to you to make that evaluation.

Thanks for the reminder on this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c: export Component: export (mod export, derive) feature Adds functionality to the library status: postponed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants